Skip to content

0.8.0 — a state holds only itself, and a step can give it up - #29

Merged
KennethGrace merged 4 commits into
mainfrom
feat/sdm-donate
Oct 8, 2026
Merged

KennethGrace merged 4 commits into
mainfrom
feat/sdm-donate

Conversation

@KennethGrace

@KennethGrace KennethGrace commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Released as 0.8.0 together with #28. The version bump is the last commit here. Merge #28 first, then this; the two combine without conflicts, and the combined tree passes 650 tests.

Closes #26.

By default, SparseDeltaMemory.step returns a new table per stream: O(B·N·d_v) a step, against O(B·(W+R)·d_v) of arithmetic. At a large batch that copy is most of a decode step, even at ordinary table sizes. This PR adds a way to waive it:

y, state = trunk.step(x, state, donate=True)   # the old state is spent

donate=True says the caller will not read state again, so a sub-layer may write its successor into the buffers it was given. The default is unchanged: every caller who doesn't opt in keeps the successor guarantee.

Shape

  • A keyword, not a step_ method. The path that needs it is a trunk stepping many streams, so the option has to pass through Block.step and Stack.step. A keyword does that, and every component keeps the same three methods.
  • Gated DeltaNet and Undertow accept the keyword and ignore it. Their successors cost no more than their own arithmetic.
  • Block passes the keyword on only when it is set. A stateful sub-layer from outside the library that doesn't take it keeps stepping, and fails loudly only if someone asks it to donate.
  • Not copy-on-write paging. Product keys spread writes across the table on purpose, so with 64-row pages, 64 writes touch essentially every page of a 32² table.

Donating is a permission, not a demand

recurrent_sparse_delta takes in_place. Both paths run one body whose last write is either scatter or scatter_, so they agree bit for bit. The layer still copies when the table is not the stream's own to write:

  • Any view. This includes init_state's broadcast. Under "learned" that broadcast shares the parameter's storage, and at B = 1 it does not overlap, so an in-place write would go through and change the learned table.
  • A table autograd is recording.
  • A table under a torch.func transform.

So the first donated step after init_state is a copy, and every step after it writes in place.

Docs

  • Design record §3.6 now carries the measurements and the reasons for this shape. Its §8 open item on an in-place decode is closed.
  • Four places said per-token work does not depend on N, with no qualification: the module docstring, the n_slots docstring, the package docstring and docs/SDM.md. They now say the arithmetic does not, and the successor copy does.
  • docs/SDM.md said "at small tables it does not matter", which is false at B = 128. Its Generation section and docs/BLOCK.md now document donate.

Measured

One machine (Tesla P40, fp32), through the API: step(x, state) against step(x, state, donate=True). Median of 3 runs of 64 steps each, after 8 warm-up steps. Outputs were bit-equal in every row.

One layer, d_model=512, n_heads=2, W=R=64, initial_memory="learned":

n_slots B = 1 B = 32 B = 128
32² 1.22 → 1.20 ms 1.34 → 1.17 ms 4.10 → 1.89 ms
64² 1.20 → 1.19 ms 3.06 → 1.15 ms 11.07 → 2.23 ms
128² 1.19 → 1.17 ms 9.70 → 1.15 ms 37.61 → 2.27 ms

Stack.step with 5 blocks, d_model=512, d_mlp=1024, SDM at blocks 1 and 3 and Gated DeltaNet elsewhere (each stack value is stable across 5 repeats):

n_slots B = 8 B = 32 B = 128
64² 7.44 → 7.42 ms 8.71 → 7.43 ms 29.16 → 11.46 ms (2.5×)
128² 7.54 → 7.52 ms 21.99 → 7.52 ms 82.33 → 11.57 ms (7.1×)

Tests

  • SDM:
    • a donated stream equals a kept one bit for bit, for both initial_memory options at B = 1 and B = 3;
    • the learned table is untouched after donated steps from init_state, under both no_grad and inference_mode;
    • after the first step the write really is in place, checked by comparing the predecessor's and successor's buffers while both are alive, so a reused allocation can't pass for one;
    • donating is declined under autograd and under vmap.
  • Stack: a donated stream equals a kept one bit for bit, and the in-place write reaches the table.
  • Block: donate reaches every stateful sub-layer only when set, and a sub-layer without the keyword still steps.

Each guard was checked by removing it: dropping the view check, dropping the transform check, ignoring donate, or passing it unconditionally each fails the test meant to catch it.

🤖 Generated with Claude Code

KennethGrace and others added 3 commits October 8, 2026 14:46
step(x, state, donate=True) says the caller will not read the state
again, so a sub-layer may write its successor into the buffers it was
given.  The default keeps the successor guarantee for everyone who does
not ask.

It is a keyword on the one method rather than a fourth method, because
the path that needs it is a trunk stepping many streams: it has to pass
through Block.step and Stack.step, and every component keeps the same
three methods.  Gated DeltaNet and Undertow accept it and ignore it --
their successors cost no more than their arithmetic.  A block passes it
on only when it is set, so a stateful sub-layer from outside the library
that predates the keyword keeps stepping, and fails loudly only when it
is asked to donate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
By default step returns a new table per stream, O(B·N·d_v) a step
against O(B·(W+R)·d_v) of arithmetic.  It read as a large-table cost and
is as much a batch cost: at B = 1 the step is launch-bound and flat in N,
which hides it.  Measured on one machine at d_model 512, a step at B = 128
went from 4.1 ms at 32² slots to 37.6 ms at 128²; donated, it is 1.9 to
2.3 ms at every size.  In a five-block trunk with two such layers the
copy was 61% of a step at 64² and 86% at 128², at B = 128.

recurrent_sparse_delta takes in_place, so both paths are one body with
scatter or scatter_ as the last write and agree bit for bit.  Donation is
a permission: the layer still copies a table that is a view -- including
init_state's broadcast, which under "learned" shares the parameter's
storage and at B = 1 would let a write through into the learned table --
and a table autograd is recording or a torch.func transform is batching.
The first donated step from init_state is therefore a copy, and the rest
are in place.  Tests hold each of those, and that the in-place write
actually happens.

The module docstring, the package's, n_slots's and the user page said
per-token work does not depend on N, unqualified.  They now say the
arithmetic does not and the successor copy does.  The record's §3.6 has
the measurements and the reasons for a keyword over a step_ method or a
paged table; its open item on an in-place decode is closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Released with #28 and this branch together.  #28 makes a state returned
by forward hold its own bytes and no more -- three mixers were returning
views that kept a larger buffer alive.  This branch adds
step(x, state, donate=True) to every mixer, Block and Stack, and Sparse
Delta Memory uses it to write the table in place rather than copying it
per stream per step.

0.8.0 rather than a patch for a keyword every step now takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KennethGrace KennethGrace changed the title sdm: a donated step writes its successor in place, and the trunk passes donate on 0.8.0 — a state holds only itself, and a step can give it up Oct 8, 2026
@KennethGrace
KennethGrace merged commit edf6a5a into main Oct 8, 2026
8 checks passed
@KennethGrace
KennethGrace deleted the feat/sdm-donate branch October 8, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SDM's step copies a full table per stream, so batched decode is bound by table size at ordinary N — the measurement §3.6 defers to

1 participant